In college football’s newly expanded playoff system, understanding a team’s championship probability matters just as much as understanding a team’s seeding. For our project, we aimed to find out if the College Football Playoff started today, and which teams would actually have the best shot at coming out on top. The new 12-team format introduced many complexities that the traditional rankings don’t capture. The top four teams get crucial first-round byes, another four host home games, and the remaining four face elimination immediately. We wanted to minimize the noise of team rankings to provide quantitative championship odds based on team strength data, giving stakeholders and fans across college football a picture of who should be planning championship parades and who is gonna need some extra luck. We used data from the CFB Fast R-package, utilizing four major analytical systems that evaluate team quality: ESPN’s Football Power Index, SP+ efficiency metric, Elo’s dynamic ratings, and the Simple Rating System. Rather than picking one system to focus on, we transformed each metric into standardized scores to make a composite measure, ensuring balanced input from predictive models, momentum-based rankings, and margin evaluations. This gave us a single strength rating for each of the 12 playoff teams drawn from the November 4th CFB rankings. From there, we implemented a Bradley-Terry model to calculate win probabilities for each matchup, ran 500 complete tournament simulations from first-round kickoffs through the championship game in an attempt to map out the probability landscape.
What we discovered was a pretty chalk playoff picture, yet stil filled with more variability than you’d expect. Ohio State sits atop the probability leaderboard, claiming the championship in roughly 27% of simulations, but still not guaranteed. Indiana trails closely with 24% title odds, while Alabama and Texas A&M each hover around 11%. These four bye recipients combine to win nearly three-quarters of all championships in our model. The next tier teams, like Oregon, Georgia, and Notre Dame, each win between 6-7% of the time, holding real but slim chances. Beyond that, lower seeds face rough odds: Memphis, Virginia, and BYU combined win fewer than one championship per hundred tournaments. We also found that hosting a first-round playoff game provides a tangible edge, with home teams advancing 55-60% of the time, though this falls well short of guaranteeing victory. One interesting thing we noted was that even favored Ohio State loses the championship in nearly three of every four tournament runs, demonstrating how playoff randomness can derail even the best programs.
The takeaways are significant for anyone invested in college football success. Securing a top-four seed and bye doesn’t just mean an easier path; it transforms championship probability, almost tripling your odds. For programs on the bubble, the difference between seed 4 and seed 5 represents a massive difference in championship equity. While our analysis has room to grow. Future iterations could incorporate team-specific pressure effects and even injuries. Which could lead to an even more accurate way to predict the 2025 CFB National Champions.
We use a simple Bradley–Terry (BT) model to convert composite team ratings into win probabilities and simulate the 12-team CFP bracket. Ratings come from FPI, SP+, Elo, and SRS (z-scored and averaged). The BT probability for team i vs j is \(p_{i,j} = \sigma(\beta_i - \beta_j), \quad \sigma(x) = \frac{1}{1+e^{-x}}\) We show results for 2025 and a 2024 back-test for comparison.
Sys.setenv(CFBD_API_KEY = "ITEs7pbYz8z66o4ksvNMW2m31B2ZPCnC+dbIXj2tT1mjMmJx02Dd9MM0fZpNhAyO")
library(cfbfastR)
library(ggplot2)
library(dplyr)
##
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
We start by adding in the 12 teams that would be in the College Football Playoffs if they started today and then pulling metrics for each team from the cfbfastR package. We used the metrics FPI, SP+, Elo, and SRS because they are strong predictors of team strength. We z-scored and averaged these to make a composite score which is then what we used as our lambdas for the Bradley-Terry model.
teams = c("Ohio State", "Indiana", "Texas A&M", "Alabama", "Georgia", "Ole Miss", "BYU", "Texas Tech", "Oregon", "Notre Dame", "Virginia", "Memphis")
fpi <- cfbd_ratings_fpi(year = 2025) |> dplyr::transmute(team, fpi)
sp <- cfbd_ratings_sp(year = 2025) |> dplyr::transmute(team, sp = rating) |> dplyr::filter(team != "nationalAverages")
elo <- cfbd_ratings_elo(year = 2025) |> dplyr::transmute(team, elo)
srs <- cfbd_ratings_srs(year = 2025) |> dplyr::transmute(team, srs = rating)
z_scores <- fpi |> dplyr::full_join(sp, by = "team") |> dplyr::full_join(elo, by = "team") |> dplyr::full_join(srs, by = "team") |> tidyr::drop_na() |>
dplyr::mutate(
z_fpi = as.numeric(scale(fpi)),
z_sp = as.numeric(scale(sp)),
z_elo = as.numeric(scale(elo)),
z_srs = as.numeric(scale(srs)),
composite = (z_fpi + z_sp + z_elo + z_srs)/4
)
ratings_com = c()
for (t in teams) {
rating = (z_scores |> dplyr::filter(team == t))$composite[1]
cat(t, ": ", rating, "\n", sep="")
ratings_com = c(ratings_com, rating)
}
## Ohio State: 2.325806
## Indiana: 2.324875
## Texas A&M: 1.607077
## Alabama: 1.657976
## Georgia: 1.621414
## Ole Miss: 1.487946
## BYU: 1.280167
## Texas Tech: 1.700043
## Oregon: 1.968132
## Notre Dame: 1.935658
## Virginia: 0.5962518
## Memphis: 0.7123528
probs_com <- 1/(1 + exp(-1 * outer(X = ratings_com, Y = ratings_com, FUN = "-")))
diag(probs_com) <- NA
round(probs_com, digits = 3)
## [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10] [,11] [,12]
## [1,] NA 0.500 0.672 0.661 0.669 0.698 0.740 0.652 0.588 0.596 0.849 0.834
## [2,] 0.500 NA 0.672 0.661 0.669 0.698 0.740 0.651 0.588 0.596 0.849 0.834
## [3,] 0.328 0.328 NA 0.487 0.496 0.530 0.581 0.477 0.411 0.419 0.733 0.710
## [4,] 0.339 0.339 0.513 NA 0.509 0.542 0.593 0.489 0.423 0.431 0.743 0.720
## [5,] 0.331 0.331 0.504 0.491 NA 0.533 0.584 0.480 0.414 0.422 0.736 0.713
## [6,] 0.302 0.302 0.470 0.458 0.467 NA 0.552 0.447 0.382 0.390 0.709 0.685
## [7,] 0.260 0.260 0.419 0.407 0.416 0.448 NA 0.397 0.334 0.342 0.665 0.638
## [8,] 0.348 0.349 0.523 0.511 0.520 0.553 0.603 NA 0.433 0.441 0.751 0.729
## [9,] 0.412 0.412 0.589 0.577 0.586 0.618 0.666 0.567 NA 0.508 0.798 0.778
## [10,] 0.404 0.404 0.581 0.569 0.578 0.610 0.658 0.559 0.492 NA 0.792 0.773
## [11,] 0.151 0.151 0.267 0.257 0.264 0.291 0.335 0.249 0.202 0.208 NA 0.471
## [12,] 0.166 0.166 0.290 0.280 0.287 0.315 0.362 0.271 0.222 0.227 0.529 NA
z_scores
## # A tibble: 137 × 10
## team fpi sp elo srs z_fpi z_sp z_elo z_srs composite
## <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 Notre Dame 21.1 21.4 2153 22.4 1.77 1.60 2.49 1.88 1.94
## 2 Oklahoma State -14.4 -17.5 1049 -14.1 -1.19 -1.41 -1.72 -1.16 -1.37
## 3 Kansas State 8.18 8 1705 7.3 0.691 0.564 0.783 0.623 0.665
## 4 Baylor 6.26 5.5 1621 3 0.530 0.370 0.462 0.264 0.407
## 5 Iowa State 8.00 9 1589 9.1 0.676 0.642 0.340 0.773 0.608
## 6 Texas Tech 18.3 24.3 1903 22.6 1.53 1.83 1.54 1.90 1.70
## 7 Kansas 5.35 5.8 1556 3.7 0.454 0.394 0.215 0.322 0.346
## 8 TCU 9.83 10.5 1700 8.5 0.828 0.758 0.764 0.723 0.768
## 9 West Virginia -2.53 -6.4 1340 -2.1 -0.203 -0.553 -0.609 -0.161 -0.382
## 10 South Florida 9.11 9.9 1736 13.9 0.768 0.712 0.901 1.17 0.888
## # ℹ 127 more rows
Round 1 hosts are seeds 5–8 vs 12–9. Then 1 vs 8/9 winner, 4 vs 5/12, 2 vs 7/10, 3 vs 6/11, followed by semifinals and championship. We run 500 Monte Carlo simulations, using our probability matrix for each matchup and tally wins by round.
design <-
data.frame(home_team = c(5, 6, 7, 8), away_team = c(12, 11, 10, 9)) |>
dplyr::rowwise() |>
dplyr::mutate(prob = probs_com[home_team, away_team]) |>
dplyr::ungroup()
n_sims <- 500
simulated_wins_r1 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_wins_r2 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_wins_r3 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_champs <- matrix(data = NA, nrow = n_sims, ncol = 12)
for(r in 1:n_sims){
set.seed(478+r)
outcomes <- rbinom(n = 4, size = 1, prob = design$prob)
round1 <-
design |>
dplyr::select(home_team, away_team) |>
dplyr::mutate(
outcome = outcomes,
winner = ifelse(outcome == 1, home_team, away_team),
winner = factor(winner, levels = 1:12)) |>
dplyr::group_by(winner) |>
dplyr::summarise(Wins = dplyr::n()) |>
dplyr::rename(Team = winner) |>
tidyr::complete(Team, fill = list(Wins = 0))
simulated_wins_r1[r,] = round1 |> dplyr::pull(Wins)
winner_512 = ifelse(round1$Wins[5] == 1, 5, 12)
winner_611 = ifelse(round1$Wins[6] == 1, 6, 11)
winner_710 = ifelse(round1$Wins[7] == 1, 7, 10)
winner_89 = ifelse(round1$Wins[8] == 1, 8, 9)
design2 <-
data.frame(high_seed = c(1, 2, 3, 4), low_seed = c(winner_89, winner_710, winner_611, winner_512)) |>
dplyr::rowwise() |>
dplyr::mutate(prob = probs_com[high_seed, low_seed]) |>
dplyr::ungroup()
design2
outcomes2 <- rbinom(n = 4, size = 1, prob = design2$prob)
round2 <-
design2 |>
dplyr::select(high_seed, low_seed) |>
dplyr::mutate(
outcome = outcomes2,
winner = ifelse(outcome == 1, high_seed, low_seed),
winner = factor(winner, levels = 1:12)) |>
dplyr::group_by(winner) |>
dplyr::summarise(Wins = dplyr::n()) |>
dplyr::rename(Team = winner) |>
tidyr::complete(Team, fill = list(Wins = 0))
simulated_wins_r2[r,] = round2 |> dplyr::pull(Wins)
winner_4512 = if (round2$Wins[4] == 1) {
4
} else if (round2$Wins[5] == 1) {
5
} else {
12
}
winner_3611 = if (round2$Wins[3] == 1) {
3
} else if (round2$Wins[6] == 1) {
6
} else {
11
}
winner_2710 = if (round2$Wins[2] == 1) {
2
} else if (round2$Wins[7] == 1) {
7
} else {
10
}
winner_189 = if (round2$Wins[1] == 1) {
1
} else if (round2$Wins[8] == 1) {
8
} else {
9
}
design3 <-
data.frame(team1 = c(winner_189, winner_2710), team2 = c(winner_4512, winner_3611)) |>
dplyr::rowwise() |>
dplyr::mutate(prob = probs_com[team1, team2]) |>
dplyr::ungroup()
design3
outcomes3 <- rbinom(n = 2, size = 1, prob = design3$prob)
round3 <-
design3 |>
dplyr::select(team1, team2) |>
dplyr::mutate(
outcome = outcomes3,
winner = ifelse(outcome == 1, team1, team2),
winner = factor(winner, levels = 1:12)) |>
dplyr::group_by(winner) |>
dplyr::summarise(Wins = dplyr::n()) |>
dplyr::rename(Team = winner) |>
tidyr::complete(Team, fill = list(Wins = 0))
simulated_wins_r3[r,] = round3 |> dplyr::pull(Wins)
champ_team1 = (round3 |> dplyr::filter(Wins != 0))$Team[1]
champ_team2 = (round3 |> dplyr::filter(Wins != 0))$Team[2]
design4 <-
data.frame(team1 = c(champ_team1), team2 = c(champ_team2)) |>
dplyr::rowwise() |>
dplyr::mutate(prob = probs_com[team1, team2]) |>
dplyr::ungroup()
design4
outcomes4 <- rbinom(n = 1, size = 1, prob = design4$prob)
round4 <-
design4 |>
dplyr::select(team1, team2) |>
dplyr::mutate(
outcome = outcomes4,
winner = ifelse(outcome == 1, team1, team2),
winner = factor(winner, levels = 1:12)) |>
dplyr::group_by(winner) |>
dplyr::summarise(Wins = dplyr::n()) |>
dplyr::rename(Team = winner) |>
tidyr::complete(Team, fill = list(Wins = 0))
simulated_champs[r,] = round4 |> dplyr::pull(Wins)
}
We first look at what percentage chance each team has to move onto the next round.
percent_results <- data.frame(matrix(nrow = 4, ncol = 0))
for (i in 1:length(teams)) {
team = teams[i]
wins1 = table(simulated_wins_r1[,i])[2]
percent1 = ifelse(i < 5, 100, round(wins1/n_sims, digits = 4)*100)
wins2 = table(simulated_wins_r2[,i])[2]
percent2 = round(wins2/n_sims, digits = 4)*100
wins3 = table(simulated_wins_r3[,i])[2]
percent3 = round(wins3/n_sims, digits = 4)*100
wins4 = table(simulated_champs[,i])[2]
percent4 = round(wins4/n_sims, digits = 4)*100
percent_results[[team]] <- c(percent1, percent2, percent3, percent4)
}
rownames(percent_results) <- c("Round 1", "Quarterfinal", "Semifinal", "Championship")
print(percent_results)
## Ohio State Indiana Texas A&M Alabama Georgia Ole Miss BYU
## Round 1 100.0 100.0 100.0 100.0 72.8 71.4 36.6
## Quarterfinal 62.8 64.8 57.6 55.4 35.8 34.4 9.4
## Semifinal 43.2 44.2 22.2 22.2 12.8 12.2 3.6
## Championship 26.8 24.2 10.8 11.2 6.8 2.0 1.0
## Texas Tech Oregon Notre Dame Virginia Memphis
## Round 1 43.2 56.8 63.4 28.6 27.2
## Quarterfinal 13.0 24.2 25.8 8.0 8.8
## Semifinal 6.6 13.4 17.0 0.8 1.8
## Championship 3.6 7.4 5.6 0.4 0.2
We then create plots for each round showing the percent chance of advancing.
team_colors <- c(
"Alabama" = "#9e1b32",
"Indiana" = "#990000",
"Ohio State" = "#BB0000",
"Texas A&M" = "#500000",
"Georgia" = "#BA0C2F",
"Ole Miss" = "#006BA6",
"Notre Dame" = "#c99700",
"Oregon" = "#007030",
"Texas Tech" = "#CC0000",
"BYU" = "#0062B8",
"Virginia" = "#F84C1E",
"Memphis" = "#003087"
)
plot_df <- percent_results |>
as.data.frame() |>
tibble::rownames_to_column("Round") |>
tidyr::pivot_longer(-Round, names_to = "Team", values_to = "Percent")
plot_r1 <- plot_df |> dplyr::filter(Round == "Round 1")
plot_r2 <- plot_df |> dplyr::filter(Round == "Quarterfinal")
plot_r3 <- plot_df |> dplyr::filter(Round == "Semifinal")
plot_r4 <- plot_df |> dplyr::filter(Round == "Championship")
round1plot = ggplot2::ggplot(plot_r1, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
geom_col(width = 0.7) +
geom_text(aes(label = sprintf("%.1f%%", Percent)),
vjust = -0.4, size = 3) +
labs(
title = "Round 1 Win Probabilities",
x = "Team",
y = "Win Probability (%)"
) +
scale_fill_manual(values = team_colors) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1)
)
round2plot = ggplot2::ggplot(plot_r2, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
geom_col(width = 0.7) +
geom_text(aes(label = sprintf("%.1f%%", Percent)),
vjust = -0.4, size = 3) +
labs(
title = "Quarterfinal Win Probabilities",
x = "Team",
y = "Win Probability (%)"
) +
scale_fill_manual(values = team_colors) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1)
)
round3plot = ggplot2::ggplot(plot_r3, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
geom_col(width = 0.7) +
geom_text(aes(label = sprintf("%.1f%%", Percent)),
vjust = -0.4, size = 3) +
labs(
title = "Semifinal Win Probabilities",
x = "Team",
y = "Win Probability (%)"
) +
scale_fill_manual(values = team_colors) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1)
)
round4plot = ggplot2::ggplot(plot_r4, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
geom_col(width = 0.7) +
geom_text(aes(label = sprintf("%.1f%%", Percent)),
vjust = -0.4, size = 3) +
labs(
title = "Championship Win Probabilities",
x = "Team",
y = "Win Probability (%)"
) +
scale_fill_manual(values = team_colors) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1)
)
ggsave("round1_plot.png", plot = round1plot, width = 8, height = 5, dpi = 300)
ggsave("round2_plot.png", plot = round2plot, width = 8, height = 5, dpi = 300)
ggsave("round3_plot.png", plot = round3plot, width = 8, height = 5, dpi = 300)
ggsave("round4_plot.png", plot = round4plot, width = 8, height = 5, dpi = 300)
round1plot
round2plot
round3plot
round4plot
The results are about as expected. Ohio State, Indiana, Alabama, and Texas A&M all have 100% chance to advance in the first round because they all have byes. We see that Ohio State and Indiana are clearly favored far above everyone else, with Indiana having a better chance to advance to the championship, but Ohio State has the slight edge on them to win that game. An interesting note is that Oregon (#9 seed) has the 5th best odds to win the national championship. Below we see what the bracket would look like based off of our model’s predictions.
We run the same exact code on the 2024 college football playoffs so we can compare how our model performed.
teams = c("Oregon", "Georgia", "Boise State", "Arizona State", "Texas", "Penn State", "Notre Dame", "Ohio State", "Tennessee", "Indiana", "SMU", "Clemson")
fpi <- cfbd_ratings_fpi(year = 2024) |> dplyr::transmute(team, fpi)
sp <- cfbd_ratings_sp(year = 2024) |> dplyr::transmute(team, sp = rating) |> dplyr::filter(team != "nationalAverages")
elo <- cfbd_ratings_elo(year = 2024, season_type = "regular") |> dplyr::transmute(team, elo)
srs <- cfbd_ratings_srs(year = 2024) |> dplyr::transmute(team, srs = rating)
z_scores <- fpi |> dplyr::full_join(sp, by = "team") |> dplyr::full_join(elo, by = "team") |> dplyr::full_join(srs, by = "team") |> tidyr::drop_na() |>
mutate(
z_fpi = as.numeric(scale(fpi)),
z_sp = as.numeric(scale(sp)),
z_elo = as.numeric(scale(elo)),
z_srs = as.numeric(scale(srs)),
composite = (z_fpi + z_sp + z_elo + z_srs)/4
)
ratings_com = c()
for (t in teams) {
rating = (z_scores |> dplyr::filter(team == t))$composite[1]
cat(t, ": ", rating, "\n", sep="")
ratings_com = c(ratings_com, rating)
}
## Oregon: 1.865358
## Georgia: 1.783524
## Boise State: 0.8969355
## Arizona State: 0.998138
## Texas: 1.970282
## Penn State: 1.79114
## Notre Dame: 2.102223
## Ohio State: 2.235929
## Tennessee: 1.614369
## Indiana: 1.600961
## SMU: 1.393184
## Clemson: 1.125629
probs_com <- 1/(1 + exp(-1 * outer(X = ratings_com, Y = ratings_com, FUN = "-")))
diag(probs_com) <- NA
round(probs_com, digits = 3)
## [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10] [,11] [,12]
## [1,] NA 0.520 0.725 0.704 0.474 0.519 0.441 0.408 0.562 0.566 0.616 0.677
## [2,] 0.480 NA 0.708 0.687 0.453 0.498 0.421 0.389 0.542 0.546 0.596 0.659
## [3,] 0.275 0.292 NA 0.475 0.255 0.290 0.231 0.208 0.328 0.331 0.378 0.443
## [4,] 0.296 0.313 0.525 NA 0.274 0.312 0.249 0.225 0.351 0.354 0.403 0.468
## [5,] 0.526 0.547 0.745 0.726 NA 0.545 0.467 0.434 0.588 0.591 0.640 0.699
## [6,] 0.481 0.502 0.710 0.688 0.455 NA 0.423 0.391 0.544 0.547 0.598 0.660
## [7,] 0.559 0.579 0.769 0.751 0.533 0.577 NA 0.467 0.620 0.623 0.670 0.726
## [8,] 0.592 0.611 0.792 0.775 0.566 0.609 0.533 NA 0.651 0.654 0.699 0.752
## [9,] 0.438 0.458 0.672 0.649 0.412 0.456 0.380 0.349 NA 0.503 0.555 0.620
## [10,] 0.434 0.454 0.669 0.646 0.409 0.453 0.377 0.346 0.497 NA 0.552 0.617
## [11,] 0.384 0.404 0.622 0.597 0.360 0.402 0.330 0.301 0.445 0.448 NA 0.566
## [12,] 0.323 0.341 0.557 0.532 0.301 0.340 0.274 0.248 0.380 0.383 0.434 NA
design <-
data.frame(home_team = c(5, 6, 7, 8), away_team = c(12, 11, 10, 9)) |>
dplyr::rowwise() |>
dplyr::mutate(prob = probs_com[home_team, away_team]) |>
dplyr::ungroup()
n_sims <- 500
simulated_wins_r1 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_wins_r2 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_wins_r3 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_champs <- matrix(data = NA, nrow = n_sims, ncol = 12)
for(r in 1:n_sims){
set.seed(478+r)
outcomes <- rbinom(n = 4, size = 1, prob = design$prob)
round1 <-
design |>
dplyr::select(home_team, away_team) |>
dplyr::mutate(
outcome = outcomes,
winner = ifelse(outcome == 1, home_team, away_team),
winner = factor(winner, levels = 1:12)) |>
dplyr::group_by(winner) |>
dplyr::summarise(Wins = dplyr::n()) |>
dplyr::rename(Team = winner) |>
tidyr::complete(Team, fill = list(Wins = 0))
simulated_wins_r1[r,] = round1 |> dplyr::pull(Wins)
winner_512 = ifelse(round1$Wins[5] == 1, 5, 12)
winner_611 = ifelse(round1$Wins[6] == 1, 6, 11)
winner_710 = ifelse(round1$Wins[7] == 1, 7, 10)
winner_89 = ifelse(round1$Wins[8] == 1, 8, 9)
design2 <-
data.frame(high_seed = c(1, 2, 3, 4), low_seed = c(winner_89, winner_710, winner_611, winner_512)) |>
dplyr::rowwise() |>
dplyr::mutate(prob = probs_com[high_seed, low_seed]) |>
dplyr::ungroup()
design2
outcomes2 <- rbinom(n = 4, size = 1, prob = design2$prob)
round2 <-
design2 |>
dplyr::select(high_seed, low_seed) |>
dplyr::mutate(
outcome = outcomes2,
winner = ifelse(outcome == 1, high_seed, low_seed),
winner = factor(winner, levels = 1:12)) |>
dplyr::group_by(winner) |>
dplyr::summarise(Wins = dplyr::n()) |>
dplyr::rename(Team = winner) |>
tidyr::complete(Team, fill = list(Wins = 0))
simulated_wins_r2[r,] = round2 |> dplyr::pull(Wins)
winner_4512 = if (round2$Wins[4] == 1) {
4
} else if (round2$Wins[5] == 1) {
5
} else {
12
}
winner_3611 = if (round2$Wins[3] == 1) {
3
} else if (round2$Wins[6] == 1) {
6
} else {
11
}
winner_2710 = if (round2$Wins[2] == 1) {
2
} else if (round2$Wins[7] == 1) {
7
} else {
10
}
winner_189 = if (round2$Wins[1] == 1) {
1
} else if (round2$Wins[8] == 1) {
8
} else {
9
}
design3 <-
data.frame(team1 = c(winner_189, winner_2710), team2 = c(winner_4512, winner_3611)) |>
dplyr::rowwise() |>
dplyr::mutate(prob = probs_com[team1, team2]) |>
dplyr::ungroup()
design3
outcomes3 <- rbinom(n = 2, size = 1, prob = design3$prob)
round3 <-
design3 |>
dplyr::select(team1, team2) |>
dplyr::mutate(
outcome = outcomes3,
winner = ifelse(outcome == 1, team1, team2),
winner = factor(winner, levels = 1:12)) |>
dplyr::group_by(winner) |>
dplyr::summarise(Wins = dplyr::n()) |>
dplyr::rename(Team = winner) |>
tidyr::complete(Team, fill = list(Wins = 0))
simulated_wins_r3[r,] = round3 |> dplyr::pull(Wins)
champ_team1 = (round3 |> dplyr::filter(Wins != 0))$Team[1]
champ_team2 = (round3 |> dplyr::filter(Wins != 0))$Team[2]
design4 <-
data.frame(team1 = c(champ_team1), team2 = c(champ_team2)) |>
dplyr::rowwise() |>
dplyr::mutate(prob = probs_com[team1, team2]) |>
dplyr::ungroup()
design4
outcomes4 <- rbinom(n = 1, size = 1, prob = design4$prob)
round4 <-
design4 |>
dplyr::select(team1, team2) |>
dplyr::mutate(
outcome = outcomes4,
winner = ifelse(outcome == 1, team1, team2),
winner = factor(winner, levels = 1:12)) |>
dplyr::group_by(winner) |>
dplyr::summarise(Wins = dplyr::n()) |>
dplyr::rename(Team = winner) |>
tidyr::complete(Team, fill = list(Wins = 0))
simulated_champs[r,] = round4 |> dplyr::pull(Wins)
}
percent_results <- data.frame(matrix(nrow = 4, ncol = 0))
for (i in 1:length(teams)) {
team = teams[i]
wins1 = table(simulated_wins_r1[,i])[2]
percent1 = ifelse(i < 5, 100, round(wins1/n_sims, digits = 4)*100)
wins2 = table(simulated_wins_r2[,i])[2]
percent2 = round(wins2/n_sims, digits = 4)*100
wins3 = table(simulated_wins_r3[,i])[2]
percent3 = round(wins3/n_sims, digits = 4)*100
wins4 = table(simulated_champs[,i])[2]
percent4 = round(wins4/n_sims, digits = 4)*100
percent_results[[team]] <- c(percent1, percent2, percent3, percent4)
}
rownames(percent_results) <- c("Round 1", "Quarterfinal", "Semifinal", "Championship")
team_colors <- c(
"Oregon" = "#007030",
"Georgia" = "#BA0C2F",
"Boise State" = "#0033A0",
"Arizona State" = "#8C1D40",
"Texas" = "#bf5700",
"Penn State" = "#041E42",
"Notre Dame" = "#c99700",
"Ohio State" = "#BB0000",
"Tennessee" = "#FF8200",
"Indiana" = "#990000",
"SMU" = "#0033A0",
"Clemson" = "#F56600"
)
plot_df <- percent_results |>
as.data.frame() |>
tibble::rownames_to_column("Round") |>
tidyr::pivot_longer(-Round, names_to = "Team", values_to = "Percent")
plot_r1 <- plot_df |> dplyr::filter(Round == "Round 1")
plot_r2 <- plot_df |> dplyr::filter(Round == "Quarterfinal")
plot_r3 <- plot_df |> dplyr::filter(Round == "Semifinal")
plot_r4 <- plot_df |> dplyr::filter(Round == "Championship")
plots = c(plot_r1, plot_r2, plot_r3, plot_r4)
round1plot = ggplot2::ggplot(plot_r1, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
geom_col(width = 0.7) +
geom_text(aes(label = sprintf("%.1f%%", Percent)),
vjust = -0.4, size = 3) +
labs(
title = "Round 1 Win Probabilities",
x = "Team",
y = "Win Probability (%)"
) +
scale_fill_manual(values = team_colors) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1)
)
round2plot = ggplot2::ggplot(plot_r2, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
geom_col(width = 0.7) +
geom_text(aes(label = sprintf("%.1f%%", Percent)),
vjust = -0.4, size = 3) +
labs(
title = "Quarterfinal Win Probabilities",
x = "Team",
y = "Win Probability (%)"
) +
scale_fill_manual(values = team_colors) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1)
)
round3plot = ggplot2::ggplot(plot_r3, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
geom_col(width = 0.7) +
geom_text(aes(label = sprintf("%.1f%%", Percent)),
vjust = -0.4, size = 3) +
labs(
title = "Semifinal Win Probabilities",
x = "Team",
y = "Win Probability (%)"
) +
scale_fill_manual(values = team_colors) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1)
)
round4plot = ggplot2::ggplot(plot_r4, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
geom_col(width = 0.7) +
geom_text(aes(label = sprintf("%.1f%%", Percent)),
vjust = -0.4, size = 3) +
labs(
title = "Championship Win Probabilities",
x = "Team",
y = "Win Probability (%)"
) +
scale_fill_manual(values = team_colors) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1)
)
ggsave("round1_2024.png", plot = round1plot, width = 8, height = 5, dpi = 300)
ggsave("round2_2024.png", plot = round2plot, width = 8, height = 5, dpi = 300)
ggsave("round3_2024.png", plot = round3plot, width = 8, height = 5, dpi = 300)
ggsave("round4_2024.png", plot = round4plot, width = 8, height = 5, dpi = 300)
We look at the percentages to advance again:
print(percent_results)
## Oregon Georgia Boise State Arizona State Texas Penn State
## Round 1 100.0 100.0 100.0 100.0 70.6 61.4
## Quarterfinal 46.4 47.8 33.4 33.2 52.4 42.8
## Semifinal 28.4 28.6 10.2 7.6 24.6 20.6
## Championship 15.6 11.6 1.8 3.2 13.4 10.6
## Notre Dame Ohio State Tennessee Indiana SMU Clemson
## Round 1 60.8 65.4 34.6 39.2 38.6 29.4
## Quarterfinal 34.4 39.8 13.8 17.8 23.8 14.4
## Semifinal 21.0 26.0 8.2 10.0 9.6 5.2
## Championship 11.6 17.6 4.2 4.4 4.8 1.2
round1plot
round2plot
round3plot
round4plot
From this we can see that our model performed fairly well on the 2024 playoffs. It got the entire first round correct, as well as predicting Ohio State (#8 seed) to win the national championship which they did. Below again we see how our model did compared to what actually happened in the playoffs:
Finally we did the same thing with the worst 12 teams in college football this year for fun.
fpi <- cfbd_ratings_fpi(year = 2025) |> dplyr::transmute(team, fpi)
sp <- cfbd_ratings_sp(year = 2025) |> dplyr::transmute(team, sp = rating) |> dplyr::filter(team != "nationalAverages")
elo <- cfbd_ratings_elo(year = 2025) |> dplyr::transmute(team, elo)
srs <- cfbd_ratings_srs(year = 2025) |> dplyr::transmute(team, srs = rating)
z_scores <- fpi |> dplyr::full_join(sp, by = "team") |> dplyr::full_join(elo, by = "team") |> dplyr::full_join(srs, by = "team") |> tidyr::drop_na() |>
mutate(
z_fpi = as.numeric(scale(fpi)),
z_sp = as.numeric(scale(sp)),
z_elo = as.numeric(scale(elo)),
z_srs = as.numeric(scale(srs)),
composite = (z_fpi + z_sp + z_elo + z_srs)/4
)
teams2 = (z_scores |> dplyr::distinct(team, .keep_all = TRUE) |> dplyr::arrange(composite) |> head(n=12))$team
ratings_com = c()
for (t in teams2) {
rating = (z_scores |> dplyr::filter(team == t))$composite[1]
cat(t, ": ", rating, "\n", sep="")
ratings_com = c(ratings_com, rating)
}
## Massachusetts: -2.407886
## Sam Houston: -1.966597
## Charlotte: -1.84027
## Kent State: -1.717262
## UL Monroe: -1.671364
## Georgia State: -1.592223
## Ball State: -1.586089
## Middle Tennessee: -1.45995
## Akron: -1.398296
## Oklahoma State: -1.371857
## Eastern Michigan: -1.331184
## Nevada: -1.293601
probs_com <- 1/(1 + exp(-1 * outer(X = ratings_com, Y = ratings_com, FUN = "-")))
diag(probs_com) <- NA
round(probs_com, digits = 3)
## [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10] [,11] [,12]
## [1,] NA 0.391 0.362 0.334 0.324 0.307 0.305 0.279 0.267 0.262 0.254 0.247
## [2,] 0.609 NA 0.468 0.438 0.427 0.407 0.406 0.376 0.362 0.356 0.346 0.338
## [3,] 0.638 0.532 NA 0.469 0.458 0.438 0.437 0.406 0.391 0.385 0.375 0.367
## [4,] 0.666 0.562 0.531 NA 0.489 0.469 0.467 0.436 0.421 0.414 0.405 0.396
## [5,] 0.676 0.573 0.542 0.511 NA 0.480 0.479 0.447 0.432 0.426 0.416 0.407
## [6,] 0.693 0.593 0.562 0.531 0.520 NA 0.498 0.467 0.452 0.445 0.435 0.426
## [7,] 0.695 0.594 0.563 0.533 0.521 0.502 NA 0.469 0.453 0.447 0.437 0.427
## [8,] 0.721 0.624 0.594 0.564 0.553 0.533 0.531 NA 0.485 0.478 0.468 0.459
## [9,] 0.733 0.638 0.609 0.579 0.568 0.548 0.547 0.515 NA 0.493 0.483 0.474
## [10,] 0.738 0.644 0.615 0.586 0.574 0.555 0.553 0.522 0.507 NA 0.490 0.480
## [11,] 0.746 0.654 0.625 0.595 0.584 0.565 0.563 0.532 0.517 0.510 NA 0.491
## [12,] 0.753 0.662 0.633 0.604 0.593 0.574 0.573 0.541 0.526 0.520 0.509 NA
design <-
data.frame(home_team = c(5, 6, 7, 8), away_team = c(12, 11, 10, 9)) |>
dplyr::rowwise() |>
dplyr::mutate(prob = probs_com[home_team, away_team]) |>
dplyr::ungroup()
n_sims <- 500
simulated_wins_r1 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_wins_r2 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_wins_r3 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_champs <- matrix(data = NA, nrow = n_sims, ncol = 12)
for(r in 1:n_sims){
set.seed(478+r)
outcomes <- rbinom(n = 4, size = 1, prob = design$prob)
round1 <-
design |>
dplyr::select(home_team, away_team) |>
dplyr::mutate(
outcome = outcomes,
winner = ifelse(outcome == 1, home_team, away_team),
winner = factor(winner, levels = 1:12)) |>
dplyr::group_by(winner) |>
dplyr::summarise(Wins = dplyr::n()) |>
dplyr::rename(Team = winner) |>
tidyr::complete(Team, fill = list(Wins = 0))
simulated_wins_r1[r,] = round1 |> dplyr::pull(Wins)
winner_512 = ifelse(round1$Wins[5] == 1, 5, 12)
winner_611 = ifelse(round1$Wins[6] == 1, 6, 11)
winner_710 = ifelse(round1$Wins[7] == 1, 7, 10)
winner_89 = ifelse(round1$Wins[8] == 1, 8, 9)
design2 <-
data.frame(high_seed = c(1, 2, 3, 4), low_seed = c(winner_89, winner_710, winner_611, winner_512)) |>
dplyr::rowwise() |>
dplyr::mutate(prob = probs_com[high_seed, low_seed]) |>
dplyr::ungroup()
design2
outcomes2 <- rbinom(n = 4, size = 1, prob = design2$prob)
round2 <-
design2 |>
dplyr::select(high_seed, low_seed) |>
dplyr::mutate(
outcome = outcomes2,
winner = ifelse(outcome == 1, high_seed, low_seed),
winner = factor(winner, levels = 1:12)) |>
dplyr::group_by(winner) |>
dplyr::summarise(Wins = dplyr::n()) |>
dplyr::rename(Team = winner) |>
tidyr::complete(Team, fill = list(Wins = 0))
simulated_wins_r2[r,] = round2 |> dplyr::pull(Wins)
winner_4512 = if (round2$Wins[4] == 1) {
4
} else if (round2$Wins[5] == 1) {
5
} else {
12
}
winner_3611 = if (round2$Wins[3] == 1) {
3
} else if (round2$Wins[6] == 1) {
6
} else {
11
}
winner_2710 = if (round2$Wins[2] == 1) {
2
} else if (round2$Wins[7] == 1) {
7
} else {
10
}
winner_189 = if (round2$Wins[1] == 1) {
1
} else if (round2$Wins[8] == 1) {
8
} else {
9
}
design3 <-
data.frame(team1 = c(winner_189, winner_2710), team2 = c(winner_4512, winner_3611)) |>
dplyr::rowwise() |>
dplyr::mutate(prob = probs_com[team1, team2]) |>
dplyr::ungroup()
design3
outcomes3 <- rbinom(n = 2, size = 1, prob = design3$prob)
round3 <-
design3 |>
dplyr::select(team1, team2) |>
dplyr::mutate(
outcome = outcomes3,
winner = ifelse(outcome == 1, team1, team2),
winner = factor(winner, levels = 1:12)) |>
dplyr::group_by(winner) |>
dplyr::summarise(Wins = dplyr::n()) |>
dplyr::rename(Team = winner) |>
tidyr::complete(Team, fill = list(Wins = 0))
simulated_wins_r3[r,] = round3 |> dplyr::pull(Wins)
champ_team1 = (round3 |> dplyr::filter(Wins != 0))$Team[1]
champ_team2 = (round3 |> dplyr::filter(Wins != 0))$Team[2]
design4 <-
data.frame(team1 = c(champ_team1), team2 = c(champ_team2)) |>
dplyr::rowwise() |>
dplyr::mutate(prob = probs_com[team1, team2]) |>
dplyr::ungroup()
design4
outcomes4 <- rbinom(n = 1, size = 1, prob = design4$prob)
round4 <-
design4 |>
dplyr::select(team1, team2) |>
dplyr::mutate(
outcome = outcomes4,
winner = ifelse(outcome == 1, team1, team2),
winner = factor(winner, levels = 1:12)) |>
dplyr::group_by(winner) |>
dplyr::summarise(Wins = dplyr::n()) |>
dplyr::rename(Team = winner) |>
tidyr::complete(Team, fill = list(Wins = 0))
simulated_champs[r,] = round4 |> dplyr::pull(Wins)
}
percent_results <- data.frame(matrix(nrow = 4, ncol = 0))
for (i in 1:length(teams)) {
team = teams2[i]
wins1 = table(simulated_wins_r1[,i])[2]
percent1 = ifelse(i < 5, 100, round(wins1/n_sims, digits = 4)*100)
wins2 = table(simulated_wins_r2[,i])[2]
percent2 = round(wins2/n_sims, digits = 4)*100
wins3 = table(simulated_wins_r3[,i])[2]
percent3 = round(wins3/n_sims, digits = 4)*100
wins4 = table(simulated_champs[,i])[2]
percent4 = round(wins4/n_sims, digits = 4)*100
percent_results[[team]] <- c(percent1, percent2, percent3, percent4)
}
rownames(percent_results) <- c("Round 1", "Quarterfinal", "Semifinal", "Championship")
team_colors <- c(
"Massachusetts" = "#971B2F",
"Sam Houston" = "#fe5100",
"Charlotte" = "#046A38",
"Kent State" = "#EAAB00",
"UL Monroe" = "#840029",
"Georgia State" = "#0039A6",
"Ball State" = "#BA0C2F",
"Middle Tennessee" = "#0066CC",
"Akron" = "#A89968",
"Oklahoma State" = "#FF7300",
"Eastern Michigan" = "#006633",
"Nevada" = "#003366"
)
plot_df <- percent_results |>
as.data.frame() |>
tibble::rownames_to_column("Round") |>
tidyr::pivot_longer(-Round, names_to = "Team", values_to = "Percent")
plot_r1 <- plot_df |> dplyr::filter(Round == "Round 1")
plot_r2 <- plot_df |> dplyr::filter(Round == "Quarterfinal")
plot_r3 <- plot_df |> dplyr::filter(Round == "Semifinal")
plot_r4 <- plot_df |> dplyr::filter(Round == "Championship")
plots = c(plot_r1, plot_r2, plot_r3, plot_r4)
round1plot = ggplot2::ggplot(plot_r1, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
geom_col(width = 0.7) +
geom_text(aes(label = sprintf("%.1f%%", Percent)),
vjust = -0.4, size = 3) +
labs(
title = "Round 1 Win Probabilities",
x = "Team",
y = "Win Probability (%)"
) +
scale_fill_manual(values = team_colors) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1)
)
round2plot = ggplot2::ggplot(plot_r2, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
geom_col(width = 0.7) +
geom_text(aes(label = sprintf("%.1f%%", Percent)),
vjust = -0.4, size = 3) +
labs(
title = "Quarterfinal Win Probabilities",
x = "Team",
y = "Win Probability (%)"
) +
scale_fill_manual(values = team_colors) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1)
)
round3plot = ggplot2::ggplot(plot_r3, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
geom_col(width = 0.7) +
geom_text(aes(label = sprintf("%.1f%%", Percent)),
vjust = -0.4, size = 3) +
labs(
title = "Semifinal Win Probabilities",
x = "Team",
y = "Win Probability (%)"
) +
scale_fill_manual(values = team_colors) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1)
)
round4plot = ggplot2::ggplot(plot_r4, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
geom_col(width = 0.7) +
geom_text(aes(label = sprintf("%.1f%%", Percent)),
vjust = -0.4, size = 3) +
labs(
title = "Championship Win Probabilities",
x = "Team",
y = "Win Probability (%)"
) +
scale_fill_manual(values = team_colors) +
theme_minimal(base_size = 14) +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1)
)
ggsave("round1_plotbowl.png", plot = round1plot, width = 8, height = 5, dpi = 300)
ggsave("round2_plotbowl.png", plot = round2plot, width = 8, height = 5, dpi = 300)
ggsave("round3_plotbowl.png", plot = round3plot, width = 8, height = 5, dpi = 300)
ggsave("round4_plotbowl.png", plot = round4plot, width = 8, height = 5, dpi = 300)
print(percent_results)
## Massachusetts Sam Houston Charlotte Kent State UL Monroe
## Round 1 100.0 100.0 100.0 100.0 40.4
## Quarterfinal 26.6 37.8 42.8 44.8 18.4
## Semifinal 9.6 15.0 20.2 20.8 9.6
## Championship 3.6 5.6 7.2 11.8 4.6
## Georgia State Ball State Middle Tennessee Akron Oklahoma State
## Round 1 42.4 45.2 48.4 51.6 54.8
## Quarterfinal 22.2 28.0 36.4 37.0 34.2
## Semifinal 11.6 14.6 20.2 20.0 18.0
## Championship 6.6 5.8 9.8 12.2 10.4
## Eastern Michigan Nevada
## Round 1 57.6 59.6
## Quarterfinal 35.0 36.8
## Semifinal 20.6 19.8
## Championship 9.6 12.8
round1plot
round2plot
round3plot
round4plot
Overall, the simulation behaved about as expected. In 2025, Ohio State and Indiana emerged as clear leaders across advancement stages, while lower seeds like Oregon and Notre Dame gained meaningful probability mass as paths opened up. It makes sense with a 12-team single-elimination bracket where mid-tier teams can string wins together. The 2024 back-test was decent, but the model overvalued Georgia relative to how the bracket actually unfolded (see Figure 2), highlighting how random the CFP can be. Taken together, the results show that top seeds benefit both from underlying strength and bracket structure, while select lower seeds can still move the needle with favorable matchups.